fix(spec): composeStacks refuses a non-array objects with an ADR-0112 envelope - #19783
Conversation
…ore the guard) Claude-Session: https://claude.ai/code/session_01VWsFyWDp8Rjb2Ma6a3Cyo8 Co-authored-by: Claude <noreply@anthropic.com>
… envelope mergeObjects iterated stack.objects unguarded: a truthy non-iterable raised a bare TypeError, a falsy non-array was skipped in silence, and a non-array iterable composed as if it were an array. It now refuses every non-array objects with STACK_SCHEMA_INVALID (status 422, the zod issue on issues with path objects), the code the strict parse raises for the same defect. A non-object entry inside an array objects is skipped and reported through the shared malformed-collection warning, and the artifact pass's object-name collector skips it too instead of dereferencing it. Claude-Session: https://claude.ai/code/session_01VWsFyWDp8Rjb2Ma6a3Cyo8 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWsFyWDp8Rjb2Ma6a3Cyo8 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin aa11e97468317d2b337213ace2dc9eab7555bebc && git checkout aa11e97468317d2b337213ace2dc9eab7555bebc
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 43e17b89053495cd30a7a2e86809e6772dcb64c4 927ea9bfa6a7f9780acb2327bf7bcc638c50b8c1 && git checkout -B drift-repro 43e17b89053495cd30a7a2e86809e6772dcb64c4 && git merge --no-ff 927ea9bfa6a7f9780acb2327bf7bcc638c50b8c1
node scripts/docs-audit/affected-docs.mjs --json 43e17b89053495cd30a7a2e86809e6772dcb64c4 |
Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Rendered by an isolated at-tier review subagent (fed the card, the ruling, the PR and the head's check-runs — not the dispatch order), adopted by Generated by Claude Code |
…instead of dropping its content (objectstack-ai#19794) Fixes objectstack-ai#19784 Clause-②: no (narrowing) Implements follow-up **C** of ruling `5690859601` (on objectstack-ai#18239, batch objectstack-ai#139 item 2, letter B, maintainer 「同意」): 「A composed artifact is complete or it is refused」. `composeStacks` step 3 (the `concat` pass) now **refuses** a stack whose value for a concatenated collection key is not an array. It uses the envelope step 2 already raises for a non-array `objects` (landed in objectstack-ai#19783). Before this change the pass kept only the array values, printed a one-time `console.warn`, and composed an artifact without that stack's grants, seed rows, views and so on. ## The measurement, per key (the card asked for each key to be re-measured before widening) Probe at base `3f9e2eaa1c`, before the fix: stack A declares the key as `[{ name: 'a_item' }]`. Stack B is hand-built and declares the same key as a map `{ b_item: { name: 'b_item' } }`. The probe composes `[A, B]` and reads whether B's content reaches the composed artifact. It ran under `manifest: 'last'` and again under `manifest: 'preserve'`. | keys | composed top-level collection | anywhere in the artifact | reading | | :--- | :--- | :--- | :--- | | `datasources`, `datasourceMapping`, `translations`, `objectExtensions`, `apps`, `views`, `viewItems`, `pages`, `dashboards`, `reports`, `datasets`, `actions`, `flows`, `jobs`, `emailTemplates`, `docs`, `books`, `positions`, **`permissions`**, `capabilities`, `sharingRules`, `apis`, `webhooks`, `agents`, `tools`, `skills`, `hooks`, `mappings`, `analyticsCubes`, `connectors`, **`data`**, `requires`, `tiers` (33) | B absent, A present, one warn | `last`: absent. `preserve`: present only inside B's package body, carrying the malformed value, so the top level and the package list disagree | **YES**: content changes | | `packages`, `plugins`, `devPlugins`, `devLogins` (4, excluded from the package body) | B absent, A present, one warn | absent in both modes | **YES**: content changes | All 37 `concat` keys read **YES** and none reads ambiguous, so the refusal covers every one. The key list is **derived** from `COMPOSE_KEY_DISPOSITIONS` in the code and in the test, never transcribed, so a key added to the table is covered the day it lands. A non-object **entry** inside an array is out of scope, as objectstack-ai#18239 already measured: it is concatenated as-is and the content is unchanged. The new test pins this. ## What changed (`packages/spec/src/stack.zod.ts`) 1. **Step 3 (the ruled change).** For each concat key and each stack: `undefined` means the key is absent and the stack is skipped. Any other non-array value (map, number, string, `null`, `false`, `Set`) throws `StackSchemaInvalidError` with `code: 'STACK_SCHEMA_INVALID'` and `status: 422`. Its `issues` carries one zod issue with `path` rooted at the key, `code: 'invalid_type'` and `expected: 'array'`. The message starts `composeStacks validation failed:`, names the stack by manifest id and position, and names the key. For a key `defineStack` accepts in the map form (`MAP_SUPPORTED_FIELDS`), the message adds the parenthetical the `objects` refusal carries. No new error code and no new export: the code the strict parse raises for the same authored mistake is reused, so `Clause-②: no` holds. 2. **Shared helper.** The value-description and zod-issue half of the `objects` refusal moved into `describeNonArrayCollection(key, value)`, and both raise sites use it. The `objects` message is byte-identical, and `compose-stacks-objects-shape-refusal.test.ts` is green unchanged. 3. **What happens to objectstack-ai#18212's step-3b collectors.** `collectSeedDataObjectErrors` and `collectPermissionGrantObjectErrors` warned and returned on a non-array `data` or `permissions`. After this change that branch has no reachable caller. `defineStack` calls the collectors only after the strict parse, which rejects the shape, and `composeStacks` calls them in step 3b, after step 3 has refused the shape. The guards stay as silent type guards, so each rule never depends on its caller's order, and the docblocks now say that. The warn calls are gone. 4. **`warnMalformedCollectionKey` becomes `warnMalformedCollectionEntry`.** With both of its `'value'` callers gone, the non-array-value sentence was dead code. The helper now carries only the entry notice (its one caller is `mergeObjects`, for a non-object `objects` entry), deduplicated per key, and its printed text is unchanged. Hunks stay out of `mergeActionsIntoObjects` (the sibling objectstack-ai#19785 edit) and out of `preservePackageEntries` and the options schema (open PR objectstack-ai#19666). The one step-3a comment line touched says "refusal" where it said "warning". `origin/main` `628e55dfa6` was merged before opening; no conflicts. ## Fixture triage (the rule's consumer radius) - `compose-stacks-key-loss.test.ts`: *"warns rather than skipping a collection key that holds a non-array value"* pinned exactly the warn-and-drop branch this removes. The case is replaced in place and now asserts the refusal (`code` + `status` + the key in the message). - `stack-artifact-crossref.test.ts`: the two cases *"a non-array `permissions` / `data` composes, and the key is warned about exactly once"* are replaced with refusal assertions (`code` + `status`), and the block header is rewritten. The entry-shape cases in the same block are unchanged and green. - `test-typecheck-debt.json`: re-recorded, because the replaced key-loss case dropped two implicit-any callback parameters (debt 4 to 3 and 3 to 2, shrink only). - Outside `packages/spec`, no test relies on the skip-and-warn text (a grep for the warning's phrasing across `packages/**` tests found none). The public face is byte-unchanged (`check:api-surface` green), so no importer owes a test. ## Tests and evidence (at `0bf2e55646`) - New `packages/spec/src/compose-stacks-concat-shape-refusal.test.ts` has 125 cases: per key, the refusal (map value), the refusal under `preserve`, and the array control that composes both stacks' entries. On `permissions` and `data` it also covers number, string, `null`, `false` and `Set`, in both positions. It adds the absent-key control, the entry-carried control and the strict-door same-code pin. - **Ablation**, run with `node scripts/ablation-replace.mjs`: the refusal was replaced by `continue` (the anchor hit 1 time and went 1 to 0, the marker went 0 to 1, blob `9b46ea3999bb` to `ed55f104c3e1`). Result: `Tests 84 failed | 41 passed (125)`, which is 37 keys times 2 refusal cases plus 10 shape rows red, with all 41 controls green. Restore: blob equal to HEAD and `git diff HEAD` empty. The test imports `./stack.zod` from `src`, so there was no `dist` leg. - `pnpm --filter @objectstack/spec test`: `Test Files 518 passed (518)`, `Tests 15213 passed | 1 todo`. - `pnpm --filter @objectstack/spec typecheck`: exit 0, after the debt re-record. - `pnpm --filter @objectstack/spec check:generated`: "All 15 generated artifacts are up to date", with dist built from this tree. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran`: "83 derived famil(ies) accounted for — 80 run, 3 NOT-MEASURED". All 80 that ran exit 0. - NOT MEASURED: `check:dual-build-cjs-loads`, reason: needs a full `pnpm build`, and that prerequisite was not met here. - NOT MEASURED: `check:type-check-debt`, reason: needs the full `./packages/*` build closure, and that prerequisite was not met here. - NOT MEASURED: `check:lean-entry-closure`, reason: needs a built `@objectstack/objectql`; the build hit a lock queue-timeout (99) behind a long-running holder. All three are left to CI. ## Acceptance notes - Out-of-scope finding (class a, not fixed here): the map-form normalizer (`normalizeMetadataCollection`) reads any object through `Object.entries`. As a result, **strict** `defineStack({ …, permissions: new Set([{…}]) })` (or a `Map`) is accepted with `permissions` equal to `[]`, and the grants are silently gone before the parse ever sees them. It is reported to the seat for filing and not touched here: it sits upstream of composition and is a different seam. - `null` and `false` carry no content, but they are refused for parity with the `objects` arm (`undefined` alone means absent). The strict parse rejects them too. --- _Generated by [Claude Code](https://claude.ai/code/session_01VWsFyWDp8Rjb2Ma6a3Cyo8)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…an ADR-0112 envelope (objectstack-ai#19798) Fixes objectstack-ai#19785 Clause-②: no (narrowing) `defineStack(config, { strict: false })` now refuses a non-array `objects`, and an `objects` array holding a non-object entry, with the ADR-0112 envelope: `StackSchemaInvalidError`, `code: 'STACK_SCHEMA_INVALID'`, `status: 422`, and the zod issue at `path: ['objects']` (non-array) or one per entry at `path: ['objects', index]` with `expected: 'object'` (non-object entry). Before, a number or a string raised a bare `TypeError: config.objects.map is not a function` from `mergeActionsIntoObjects`, and a `null` entry raised one reading `actions` off it. No new error code; the error class stays module-local. ## Refuse or skip: the card's first question **Refuse.** Here is what `DefineStackOptions.strict` declares for `false`: "validation is skipped for maximum flexibility (e.g., when views reference objects provided by other plugins). Use this ONLY when you need to bypass validation for advanced use cases." It promises that validation is skipped: cross-references and schema detail. It does not promise to accept, or quietly skip, a shape the action merge cannot read. No ADR is cited there. So this is not a contract-direction question, and the objectstack-ai#18239 ruling (`5690859601`) applies one door earlier: 「an ADR-0112 envelope error (closed `error.code`, status), ⛔ not a bare `TypeError` and ⛔ not a skip」. ## What changed `packages/spec/src/stack.zod.ts`, inside `mergeActionsIntoObjects` only: 1. **Array guard.** Any `objects` that is present and not an array is refused. `undefined` is the only non-array that is not malformed, because the key is absent. This is the line objectstack-ai#19783 drew for `mergeObjects`. It includes the falsy values `null`, `''`, `0` and `false`, which used to be handed on untouched and then refused one call later by `composeStacks` with this same code. Refusing `5` while passing `0` would repeat the silent-skip row objectstack-ai#19783 removed. The map form (`{ name: { ... } }`) is normalized to an array before the merge and is still accepted. 2. **Non-object entries (rework round 1).** An entry that is not an object (`null`, `7`, a string, an array) is refused with the same envelope: one zod issue per such entry at `['objects', index]`, `expected: 'object'` (read from `z.array(z.looseObject({}))`, re-rooted at `objects`, which is the strict parse's own answer for the shape). The message names each position. Round 0 handed such an entry on untouched. The contract review showed that this widens the accept set under a `(narrowing)` declaration: the card's own `[null, obj]` repro threw before and would have succeeded. It would also let the next consumer, plugin-object registration in `packages/objectql/src/engine.ts` (one loop in one warn-level catch), silently drop every object after the bad entry. Now every row narrows. Measured on the base `3f9e2eaa1c`, against the built `dist`: | `objects` under `strict: false` | before | after | | :-- | :-- | :-- | | `5`, `'abc'` | `TypeError: config.objects.map is not a function`, `code`/`status` undefined | `STACK_SCHEMA_INVALID`, 422 | | `null`, `''`, `0`, `false` | returned untouched | `STACK_SCHEMA_INVALID`, 422 | | `[null, obj]` | `TypeError: Cannot read properties of null (reading 'actions')` | `STACK_SCHEMA_INVALID`, 422, issue at `['objects', 0]` | | `[obj, 7]` | returned with the entry in place | `STACK_SCHEMA_INVALID`, 422, issue at `['objects', 1]` | | map form, array, absent | accepted | accepted (unchanged) | ## Tests - New file: `packages/spec/src/define-stack-non-strict-objects-shape-refusal.test.ts`. It has 6 non-array refusal rows asserting `code` + `status` + the issue (`path`, `invalid_type`, `expected: 'array'`). Two entry refusal rows cover `[null, obj, 7]`, with issues exactly at `['objects', 0]` and `['objects', 2]`, all `invalid_type` / `expected: 'object'`, plus `[null, obj]`. Controls: array, map form and absent `objects` are all accepted with the bound action merged, and the strict door raises the same code for the same shape. - `packages/spec/src/compose-stacks-objects-shape-refusal.test.ts` (objectstack-ai#19783's file): its four falsy rows reached composition through `strict: false`. That door now refuses those shapes itself, so the rows now reach composition as hand-built stacks. They keep the same values and the same assertions. This is a fixture re-route, and it is outside the claim's declared file surface. I am declaring it here; the contract review accepted it. - **Ablation, round 0** (commit `d3959b7b79`): `stack.zod.ts` restored from the base, with the new file run against it: `Tests 7 failed | 4 passed (11)`. The refusal rows failed with `expected undefined to be 'STACK_SCHEMA_INVALID'` and the entry row with a bare `TypeError`. Restored, `git diff HEAD` empty. - **Ablation, round 1** (commit `c5a865c769`): `scripts/ablation-replace.mjs` replaced the entry guard's condition. The anchor went x1 to x0 and the blob `88f36c9846` became `ac5fbf58be`. Result: `Tests 2 failed | 10 passed (12)`. Both entry rows failed with `expected undefined to be 'STACK_SCHEMA_INVALID'` (a bare `TypeError` reached the assertion), and every other row stayed green. Restore: blob back to the HEAD blob `88f36c9846`, `git diff HEAD` empty. - At `c5a865c769` (merge of `origin/main` `fae870352e`): - `pnpm --filter @objectstack/spec build` + `typecheck`: exit 0. The test layer compiles, and `test-typecheck-debt.json` held. - The two targeted files: 37/37. - Full spec suite: `Test Files 552 passed (552)`, `Tests 15690 passed | 1 todo`. - eslint `--no-inline-config` over the 3 touched TS files: 3 files, 0 errors, 0 warnings. Type-aware linting is not enabled in `eslint.config.mjs`, so this diff cannot move the verdict on any untouched file. - `dispatch-gates --commands` derived 82 commands (the same list as round 0). 80 exit 0. 2 are NOT MEASURED with exit 3 (they need the whole-workspace build): `check:dual-build-cjs-loads` and `check:type-check-debt`. `--ran` reconcile: 82 accounted, 0 UNRUN. - **Round 1b, at `ce893bbf31`** (merge of `origin/main` `0b83e01627`, which includes objectstack-ai#19794, then the helper routing): - The targeted files plus objectstack-ai#19794's `compose-stacks-concat-shape-refusal.test.ts`: 162/162. - Spec build + typecheck: exit 0. - Full spec suite: `Test Files 553 passed (553)`, `Tests 15815 passed | 1 todo`. - Ablation of the array guard: `Tests 6 failed | 6 passed (12)` (all 6 non-array rows). Ablation of the entry guard: `Tests 2 failed | 10 passed (12)`. Both restores ended at blob == HEAD `ac2a452f57`, `git diff HEAD` empty. - eslint: 3 files, 0 errors. - Gates: 82 derived, 80 exit 0, the same 2 NOT MEASURED, 0 UNRUN. - `origin/main` has since moved one commit, `de4ed33fd5` (docs(pm), objectstack-ai#19795). It is not merged. ## Acceptance notes - `class: a`, not fixed here, filed by the seat as objectstack-ai#19799. The same function still dereferences the other shapes it reads under `strict: false`: a top-level `actions` of `5` or `'abc'`, or `actions: [null]`, raises a TypeError from `sortActionsByOrder`. - Review item 4 is done at `ce893bbf31`. PR objectstack-ai#19794 landed (`0b83e01627`), so the non-array block now calls its `describeNonArrayCollection('objects', …)` helper, with no hand-copied kind/issues code left. Messages are byte-stable for every input that can reach this door. The helper's one extra branch, `'an object'` for a plain object, is unreachable here because `normalizeStackInput` turns the map form into an array first. - `strict: false` with a `Set` as `objects` still returns without throwing; the normalizer reads it as a keyless map. This was recorded on objectstack-ai#19783 and is not part of this card. --- _Generated by [Claude Code](https://claude.ai/code/session_01VWsFyWDp8Rjb2Ma6a3Cyo8)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #18239
Clause-②: no (narrowing)
Implements ruling
5690859601(batch #139 item 2, letter B):composeStacksstep 2 (mergeObjects) refuses a stack whoseobjectsis not an array, with an ADR-0112 envelope. It no longer raises a bareTypeError, and it no longer skips the stack. A non-object entry inside an arrayobjectsis skipped and reported throughwarnMalformedCollectionKey, the way step 3 handles a malformed collection.What changed
packages/spec/src/stack.zod.ts, three hunks, none of them inside the regions open PR #19666 edits (ObjectStackDefinitionSchemaat ~1408,ComposeStacksOptionsSchema,preservePackageEntries, thecomposeStacksdocblock and body). I read that PR's hunk list before editing.mergeObjects, the ruled change. Ifobjectsisundefined, the key is absent and the stack is skipped as before. Any other non-array value throwsStackSchemaInvalidError:code: 'STACK_SCHEMA_INVALID',status: 422.issuescarries one real zod issue (z.array(z.unknown()).safeParseof the value, withpathprefixed['objects'], socode: 'invalid_type'andexpected: 'array'). The message startscomposeStacks validation failed:, names the stack by manifest id and position, and names the key'objects'. A non-object entry is skipped and reported throughwarnMalformedCollectionKey('objects', 'entry').warnMalformedCollectionKeygains an optionalshapeargument. The default'value'path is unchanged byte for byte. The new'entry'path prints an accurate sentence ("an entry in it that is not an object"), because the existing sentence says "a non-array value", which is false for an entry. The two shapes are deduplicated separately.collectObjectNamesskips a non-object entry. Without this, step 3b (collectArtifactCrossReferenceErrors) reads the raw input'sobjectsand still raisesTypeError: Cannot read properties of null (reading 'name')on an entry that step 2 had just skipped. The ruling's entry half cannot hold end to end without it. This is one line outside themergeObjectsbody that the claim fenced: same file, same defect class, not in feat(spec)!: a multi-package artifact carries its metadata once, in packages[] (#14512) #19666's hunks.Why
STACK_SCHEMA_INVALIDand not a new code. The ruling asks for a closederror.code. The strictdefineStackparse already refuses this exact authored mistake withSTACK_SCHEMA_INVALID(anumberinobjectsraises it; the new test pins that). Reusing it gives one code for one defect, whichever door catches it. The header names the pass and the code names the rule, the same splitSTACK_CROSS_REFERENCE_INVALIDalready makes across itsdefineStackandcomposeStacksraise sites. It also adds no ledger row and noErrorCodemember, which keeps the claim'sClause-②: notrue: the public face does not grow, only the accept set narrows. ASTACK_COMPOSE_*spelling would also be wrong by that family's own docblock, which reserves it for disagreements between stacks.Red before the guard, green after
The fixture is
packages/spec/src/compose-stacks-objects-shape-refusal.test.ts(25 cases). The subject is imported from./stack.zod(source, notdist), so the ablation needs no rebuild.22c2465fbefore the fix existed):Tests 23 failed | 2 passed (25).862bc7bb):Tests 25 passed (25).node scripts/ablation-replace.mjs. It swapped the anchorif (!Array.isArray(declared)) {for the base'sif (!declared) continue;behaviour (anchor 1 to 0, blob66d0db3fe4ccto486f0a54245a). Result:Tests 21 failed | 4 passed (25). The 21 are exactly the per-row refusal cases. The tool then restored the file: blob equals HEAD66d0db3fe4cc,git diff HEADempty.What the same composition (a well-formed stack plus a second stack whose
objectsis X) did at the ablated state compared with the fix, from a direct probe:5TypeError("… is not iterable"),codeandstatusundefinedSTACK_SCHEMA_INVALID, 422null,'',0,false["a_item"](the stack's objects silently absent)STACK_SCHEMA_INVALID, 422Setof objectsSTACK_SCHEMA_INVALID, 422The
strict: falsepath is exercised: four rows go throughdefineStack(config, { strict: false }).The ruling's measurement: does skipping a malformed
permissions/datachange the composed artifact's content?Yes, for a non-array value; no, for a non-object entry. Probed at HEAD. Stack A has
permissions: [{ name: 'pa' }], and stack B has a hand-built non-arraypermissions: { name: 'pb', … }. The result iscomposed.permissions = ["pa"]: B's grant is absent from the artifact, and only the #5005 warning mentions it.databehaves the same way (composed.data = ["a_item"], B's dataset absent). The loss happens in step 3, the concat pass (CONCAT_ARRAY_FIELDS), not in step 3b's collectors: #18212's skip there only affects validation. A non-object entry (permissions: [null, {…}]) is carried into the artifact as is ([null, {"name":"pb",…}]), so its content is unchanged. The ruling says this answer goes back to the card for the spec lane to file C. I have not widened anything here.Verification
Run at HEAD
927ea9bfa6. That commit only adds the changeset on top of862bc7bb, so no source changed after the test runs. Heavy runs went throughscripts/pm/os-verify-lock.sh, and each result below is itsVERDICT command-exitline.pnpm --filter @objectstack/spec test+typecheck(which includescheck:test-typecheckover the test layer): exit 0.Test Files 516 passed | 1 skipped (517),Tests 15079 passed | 1 skipped | 1 todo, andcheck:test-typecheck: OK — 53 file(s) / 257 error(s) / 142 pinned signature(s) held. The new test file compiles with no new debt.pnpm --filter @objectstack/spec build, thencheck:generated: exit 0,All 15 generated artifacts are up to date.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 82 commands. All were run and exit codes recorded before any pipe.--ranreconciliation:82 derived famil(ies) accounted for — 80 run, 2 NOT-MEASURED.check:dual-build-cjs-loadsandcheck:type-check-debt. Both exit 3 (PREREQUISITE NOT MET) because they need every workspace package'sdist, which means a fullpnpm build. That is left to CI.check-plugin-teardown-shape --self-test(fetched its pinned control commit),check:doc-formula-expressions(built formula and lint),check:lean-entry-closure(built objectql).check-adr-0087-registration: the changeset is detected as[BREAKING+clause-②-narrowing]with dispositionnot-required (no-migration-prescription).eslint --no-inline-config --format jsonon the 2 changed.tsfiles gives files 2, errors 0, warnings 0.eslint.config.mjsenables no type-aware linting (noparserOptions.project), so this diff cannot change any verdict on an untouched file. The repo-widepnpm lintis CI's.Changeset
.changeset/18239-merge-objects-refusal.md:minor, with the BREAKING banner (a public root export now refuses a class of input), a before/after table, theClause-②: no (narrowing)line, and the ADR-0087 dispositionnot-required (no-migration-prescription). No authorable key, export or stored shape moves, and the strict parse already refused every input this refuses.Acceptance notes
composeStacks([oneStack])returnsstacks[0]untouched, so a single input is never refused here. This is the same declared boundary the artifact cross-reference pass states.defineStack(config, { strict: false })withobjectsset to aSetreturns without throwing. The map-form normalizer reads the Set as a map with no keys. Contrived, and no author writes it.strict: falsedoor crashes before composition is reached, inmergeActionsIntoObjects.defineStack({ …, objects: 5 }, { strict: false })givesTypeError: config.objects.map is not a function.objects: [null, …]givesTypeError: Cannot read properties of null (reading 'actions'). Both havecodeandstatusundefined. It is the same family, but a different function and a different door, so it is out of this card's scope.Generated by Claude Code